defmodule EctoWorkflow do
  @moduledoc """
  Workflow implementation based on Ecto.

  ## Example

      defmodule Article do
        use Ecto.Schema
        use EctoWorkflow, state_field: :workflow_state
      
        schema "articles" do
          field :workflow_state, :string, default: "new"
        end
      
        workflow do
          state :new do
            event :submit, transitions_to: :awaiting_review
          end
      
          state :awaiting_review do
            event :review, transitions_to: :being_reviewed
          end
      
          state :being_reviewed do
            event :accept, transitions_to: :accepted
            event :reject, transitions_to: :rejected
          end
      
          state :accepted
          state :rejected
        end
      end
  """

  defmacro __using__(options) do
    quote bind_quoted: [options: options] do
      @state_field options[:state_field] || :workflow_state
    end
  end

  defmacro workflow(do: {:__block__, _, state_specs}) do
    event_specs = extract_event_specs(state_specs)  # [{state_name, {event_name, next_state_name}}]
    event_functions = Enum.map(event_specs, &define_event_function/1)
    {:__block__, [], event_functions}
  end

  defp extract_event_specs(state_specs) do
    state_specs
    |> Enum.flat_map(&extract_events/1)  # [{state_name, {event_name, next_state_name}}]
    |> Enum.sort(fn{state_name, {event_name, _}}-> event_name end)
  end

  # state without events
  defp extract_events({:state, _, [state_name]}), do: []

  # state with a single event
  defp extract_events({:state, _, [state_name, do: {:event, _, [event_name, [transitions_to: next_state_name]]}]}) do
    [{state_name, {event_name, next_state_name}}]
  end

  # state with multiple events
  defp extract_events({:state, _, [state_name, do: {:__block__, _, event_specs}]}) do
    event_specs
    |> Enum.map(fn(event_spec)-> extract_events({:state, [], [state_name, do: event_spec]}) end)
  end

  defp define_event_function({state_name, {event_name, next_state_name}}) do
    quote do
      def unquote(event_name)(%{@state_field => unquote(to_string(state_name))} = data) do
        data
        |> Ecto.Changeset.change()
        |> unquote(event_name)()
      end

      def unquote(event_name)(%Ecto.Changeset{data: %{@state_field => unquote(to_string(state_name))}} = data) do
        if can?(unquote(event_name), data) do
          Ecto.Changeset.change(data, %{@state_field => unquote(next_state_name)})
        else
          Ecto.Changeset.add_error(data, :transition, "not permitted", [transition: unquote(event_name), from: unquote(state_name), to: unquote(next_state_name)])
        end
      end
    end
  end
end
